Skip to content

前端构建工具的演进历程

随着 Web前端工程化、模块化、团队化的逐步演进和发展,业界涌现出了一系列的前端构建工具。 它们的出现,让前端工程的开发和集成环境日趋完善和成熟,同时也丰富了前端的生态。 从无到有,从有到优,前端构建工具也是一步一步发展和完善的。 本文就 Web前端领域的构建工具进行梳理,列举出比较有代表性的几款构建工具,形成构建工具的发展脉络。

构建工具的发展史

构建其实是⼯程化、⾃动化思想在前端开发中的体现(个⼈认为狭义的⼯程化就是构建⼯具+CI/CD),将⼀系列流程⽤代码去实现,让代码⾃动化地执⾏这⼀系列复杂的流程。 构建为前端开发注⼊了更⼤的活⼒,解放了我们的⽣产⼒。

站在当前的视角往前看,回顾Web前端领域的主流构建工具和模块化的演进发展,大致汇总如下:

  • 2009 年,Kevin Dangoor 发起了 ServerJS 项⽬,后更名为 CommonJS,其⽬标是指定浏览器外的 JS API 规范(例如 FS、Stream、Buffer 等)以及模块规范 Modules/1.0。这⼀规范也成为同年发布的 NodeJS 中的模块定义的参照规范
  • 2011 年,RequireJS 1.0 版本发布,作为客户端的模块加载器,提供了异步加载模块的能⼒。作者 在之后提交了 CommonJS 的 Module/Transfer/C 提案,这⼀提案最终发展为了独⽴的 AMD 规范
  • 2013 年,Grunt、Gulp第⼀版相继发布,同年,⾯向浏览器端模块的打包⼯具Browserify发布
  • 2014 年,跨平台的前后端兼容的模块化定义语法 UMD发布
  • 2014 年,Sebastian McKenzie 发布了将 ES6 语法转换为 ES5 语法的⼯具 6to5,并在之后更名为Babel
  • 2014 年,Guy Bedford 对外发布了 SystemJS 和 jspm ⼯具,⽤于简化模块加载和处理包管理
  • 2014 年,打包⼯具 Webpack 发布了第⼀个稳定版本
  • 2015 年,ES6(ES2015)规范正式发布,第⼀次从语⾔规范上定义了 JS 中的模块化
  • 2015 年,Rich Harris 发布的 Rollup 项⽬,基于 ES6 模块化,提供了 Tree Shaking 的功能
  • 2017 年,Parcel 鉴于当时的 Webpack 使⽤上过于繁琐,官⽅⽂档也不是很清晰明了。所以⼀发布就被推上了⻛⼝浪尖
  • 2019 年,snowpack将node_modules转为ESM的构建⼯具开始出现
  • 2020 年,随着浏览器对ESM和HTTP2的⽀持,bundleless 思路也开始出现,esbuild此时开始出现在视野⾥,同时snowpack也开始内置使⽤esbuild

由此可⻅,随着前端技术的演进,整体的构建⼯具也随之演进,后⽂主要对⽬前为⽌⽐较流⾏的构建⼯具进⾏分析,主要包括以下⼏类:

  1. 初版构建⼯具;
  2. 现代打包构建⼯具基⽯--Webpack
  3. 基于webpack改进的构建⼯具;
  4. 突破JS语⾔特性的构建⼯具;
  5. 基于ESModule的 bundleless 构建⼯具;

初版构建工具

Grunt

Grunt可以说是前端(JS语⾔)中第⼀个正式的构建⼯具,它基于 Node.js ,⽤ JS 开发,这样就可以借助 Node.js 实现跨系统跨平台的桌⾯端的操作,例如⽂件操作等等。 此外,Grunt 以及插件都作为⼀个 包 ,可以⽤ NPM 安装进⾏管理,但对于像webpack上很多能⼒,如HMR、Scope Hoist等都是不⽀持的,可以作为学习webpack前的了解。

Grunt其实更像是⼀种⾃动化的配置⼯具集,就像官⽅说的,Grunt是 The JavaScript Task Runner ,使⽤上,Grunt 是由 JSON 配置设置驱动,并且每个 Grunt 任务通常必须创建中间⽂件将结果传递给其他任务。 所能实现的功能包括:检查每个 JS ⽂件语法、合并两个 JS ⽂件、将合并后的 JS ⽂件压缩、将 SCSS ⽂件编译等,包括我们上⾯提到的使⽤ gulp 将.less⽂件转换为.css⽂件等等。

Grunt的插件

Gulp

Gulp跟Grunt类似,都是基于task驱动执⾏的,可以完成javascript/coffee/sass/less/html/image/css等⽂件的的测试、检查、合并、压缩、格式化、浏览器⾃动刷新、部署⽂件⽣成。 Gulp的优点在于Gulp 更倾向于写代码的⽅式,但相关的插件资源不如Grunt。 现在前端项目开发已经基本不使⽤Gulp了,作为了解即可。

Gulp的插件

现代打包构建工具基石-Webpack

Webpack 作为当下前端开发领域的主流构建工具,其影响力毋庸置疑,生态链极其丰富。 相当多的团队在技术选型时,会将 Webpack 做打包构建工具。 很多三方的脚手架中也内置了 Webpack,比如react的脚手架工具CRA。

Webpack的功能十分丰富,提供的可选配置繁多,要想全面掌握需要花费一定的时间。 同时,Webpack基于事件的插件设计体系,存在丰富的插件。 提出了loader的概念,将一切文件视为模块(非JS文件通过 loader 转成 Webpack 识别的模块)来处理。

通过配置程序入口,找到模块的依赖关系,借助 loader 实现模块化的解析处理,再经过 plugin的深层加工,按照 输出配置的要求打包到 bundle 中,最后输出到指定路径下。 关于 Webpack 介绍的文档非常多,作为主流的构建工具,大家基本都使用过,不再过多阐述。

Webpack 也是有着自身的缺点。

  • 开发模式下,启动慢。大型项目要构建的模块非常多,耗时长。
  • 配置繁杂,对初学者不友好,甚至有 “Webpack 配置工程师”这样的调侃
  • 作为 bundle 模式的典型,所有的模块集中打包处理,没有充分利用 ESModule 的优势

当然 Webpack 也是在不断的迭代演进。 最新的 Webpack 5 已经将很多配置内置化了,按照“约定大于配置”的理念进行了优化。 分为 默认, development, production 三种模式,很多配置都简化了。 同时也推出了像 模块联邦(module federation)这样的模块复用的新特性。

基于Webpack改进的构建工具

Rollup

rollup与webpack⼀样,都是通过解析JavaScript的依赖树将代码输出为指定版本的JavaScript,供浏览器或者node环境执⾏。 不同的是rollup相对webpack更轻量,其构建的代码并不会像webpack⼀样被注⼊⼤量的webpack内部结构,⽽是尽量的精简保持代码原有的状态。 且配置相对简单,但因为没有devServer和HMR,所以⼀般rollup⽤于JS库开发,⽽⾮业务开发。

rollup⽣成代码只是把我们的代码转码成⽬标JS,同时如果需要,他可以同时帮我们⽣成⽀持 umd/commonjs/es的js代码,vue/react/angular都在⽤他作为打包⼯具。 查看他们的官⽹代码都可以看到rollup的影⼦。

rollup使⽤

浏览器环境使⽤的话:

  1. ⽆需考虑浏览器兼容问题

开发者写esm代码 -> rollup通过⼊⼝,递归识别esm模块 -> 最终打包成⼀个或多个bundle.js -> 浏览器直接可以⽀持引⼊ <script type="module">

  1. 需考虑浏览器兼容问题

可能会⽐较复杂,需要⽤额外的polyfill库,或结合webpack使⽤

打包成npm包的话:

开发者写esm代码 -> rollup通过⼊⼝,递归识别esm模块 -> (可以⽀持配置输出多种格式的模块,如esm、cjs、umd、amd)最终打包成⼀个或多个bundle.js

  • (开发者要写cjs也可以,需要插件@rollup/plugin-commonjs)
  • 很明显,rollup ⽐较适合打包js库(react、vue等的源代码库都是rollup打包的),这样打包出来的库,可以充分使⽤上esm的tree shaking,使源库体积最⼩

针对⼀个相同的demo,webpack和rollup打包出的体积相差极⼤:

webpack诞⽣于ESM标准出来前,CommonJs出来后,当时的浏览器只能通过script标签加载模块。 script标签加载代码是没有作⽤域的,只能在代码内⽤ IIFE(立即执行函数) 的⽅式实现作⽤域效果, 这就是webpack打包出来的代码 ⼤结构都是IIFE的原因,并且每个模块都要装到function⾥⾯,才能保证互相之间作⽤域不⼲扰。 这就是为什么 webpack打包的代码为什么乍看会感觉乱,找不到⾃⼰写的代码的真正原因。 同时,webpack的代码注⼊问题,是因为浏览器不⽀持 CommonJS 规范,所以webpack要去⾃⼰实现 require 和 module.exports⽅法(才有很多注⼊)(webpack⾃⼰实现polyfill)。

那这么多年过去了,浏览器为什么不⽀持CommonJS规范呢?

  1. cjs是同步的,运⾏时的,node环境⽤cjs,node本身运⾏在服务器,⽆需等待⽹络握⼿,所以同步处理是很快的
  2. 浏览器是 客户端,访问的是服务端资源,中间需要等待⽹络握⼿,可能会很慢,所以不能 同步的卡在那⾥等服务器返回的,体验太差
  3. 后续出来esm后,webpack为了兼容以前发在npm上的⽼包(并且当时⼼还不够决绝,导致这种“丑结构的包”越来越多,以后就更不可能改这种“丑结构了”),所以保留这个IIFE的结构和代码注⼊,导致现在看webpack打包的产物,乍看结构⽐较乱且有很多的代码注⼊,⾃⼰写的代码都找不到。

rollup诞⽣于esm标准出来后,就是针对esm设计的,也没有历史包袱,所以可以做到真正的“打包”(精简,⽆额外注⼊)

总结

rollup在构建JavaScript⽅⾯⽐webpack有更⼤的优势:

  1. 构建速度明显快于webpack;
  2. ⽣成的代码量很⼩;
  3. 配置⽅式其实⾮常简单;

不过在应⽤开发层⾯讲,如果开发⼀个Web应⽤webpack要⽐rollup有更⼤的优势,因为其天然继承了devServer以及HMR,这使得开发者可以快速的对应⽤进⾏调试开发,同时webpack⾃身庞⼤且成熟的⽣态体系也让他更加适合应⽤开发,所以最终总结的就是rollup更加适合JS库开发,⽽webpack更加适合应⽤开发。

Parcel

Parcel诞⽣有很强的历史背景,当时webpack上各种配置过于繁琐,且官⽅⽂档也不清晰,导致⼈们开始转向其他的打包⼯具,这时Parcel就诞⽣了

Parcel的特点:

  • 完全零配置;
  • 构建速度更快;
  • ⾃动安装依赖,开发更加便捷;

相较于Webpack,Parcel是以assets⽅式组织的,assets 可以是任意⽂件,所以你可以构建任意⽂件。 ⽽在 webpack 中,必须是以 JS 为⼊⼝去组织其他⽂件,这算是⼀个体验上的升级。

同时,速度快也是Parcel的优势,原因主要在于Parcel⽀持多核(通过worker平⾏构建)和⽂件系统缓存(⼆次构建会快,使⽤C++缓存,效率更⾼,和 webpack 的 dll 异曲同⼯),不过⽬前Webpack也有多核处理loader和压缩的插件。 关于 0 配置。ParcelJS 本身是 0 配置的,但 HTML、JS 和 CSS 分别是通过 posthtml、babel 和 postcss 处理的,所以我们得分别配 .posthtmlrc 、 .babelrc 和 .postcssrc 。 所以Parcel更适⽤于⼩型简单的项⽬,定制化需求⾼的还是建议使⽤webpack(毕竟社区资源丰富)

总结 Parcel只能说是时代的产物,Parcel的code splitting、HMR、sourcemap、publicPath、tree shaking、scope hoist、share module、UMD等基本的能⼒还是提供了的,⽽且官⽅也在不断的维护,建议简单项⽬可以尝试使⽤。

Parcel-awesome

突破 JS 语言特性的构建工具

SWC(Speedy Web Compiler)

SWC虽然名义上是编译器,但它实际上是compiler + bundler,只不过⽬前的bundler功能还有待完善提⾼。

SWC介绍

SWC意指(Speedy Web Compiler)快速的web编译器,是⽤RUST实现的。主要针对JS使⽤了多线程的能⼒。 我们知道,Webpack与Babel的性能瓶颈都在于JS语⾔,出现了 go 实现的Esbuild 与 Rus实现的 SWC等⼯具。

其中,SWC的⽬标则是是替代babel,本身是为了对标babel进⾏设计的,可以看到⼤部分babel的功能SWC也对标实现了。 具体的配置内容跟babel类似,且在webpack中,也有类似 babel-loader 的 swc-loader ,我们 可以在根⽬录下设置.swcrc⽂件,可以指定常⻅的编译内容,浏览器的⽀撑、模块化、代码压缩以及打包,具体的配置⻅链接

除此之外,swc还⽀持插件的形式(基本上babel的能⼒都⽀持),但整体来看,还是有很多功能正在路 上,鉴于 @babel/types 类似能⼒的缺失、TS⽀持程度等问题,还是建议在⽣产环境再观察⼀段时间,后续有条件再使⽤。

SWC的使用

swc 与 babel ⼀样,将命令⾏⼯具、编译核⼼模块分化为两个包。

  • @swc/cli 类似于 @babel/cli;
  • @swc/core 类似于 @babel/core;
sh
npm i -D @swc/cli @swc/core
npm i -D @swc/cli @swc/core

通过如下命令,可以将⼀个 ES6 的 JS ⽂件转化为 ES5。

sh
npx swc source.js -o dist.js
npx swc source.js -o dist.js
js
const start = () => {
 console.log('app started')
}
// 转为
var start = function() {
 console.log("app started");
};
const start = () => {
 console.log('app started')
}
// 转为
var start = function() {
 console.log("app started");
};

配置的格式与babel类似,在根⽬录下使⽤ .swcrc,配置的格式为 JSON。

Esbuild

可能很多前端开发者对于 Esbuild 的了解停留在Vite是使⽤Esbuild进⾏预构建依赖的,这也是为什么vite编译那么快的原因之⼀,下⽂将详细介绍下Esbuild的使⽤。

Esbuild为什么快

Alt text

  1. 使⽤Go

JavaScript 本质上依然是⼀⻔解释型语⾔,JavaScript 程序每次执⾏都需要先由解释器⼀边将源码翻译成机器语⾔,⼀边调度执⾏; ⽽ Go 是⼀种编译型语⾔,在编译阶段就已经将源码转译为机器码,启动时只需要直接执⾏这些机器码即可。 也就意味着,Go 语⾔编写的程序⽐ JavaScript 少了⼀个动态解释的过程。

  1. 多线程

Go 天⽣具有多线程运⾏能⼒,对打包过程的解析、代码⽣成阶段进⾏了深度定制,⽽ JavaScript 本质上是⼀⻔单线程语⾔,直到引⼊ WebWorker 规范之后才有可能在浏览器、Node 中实现多线程操作,除了 CPU 指令运⾏层⾯的并⾏外,Go 语⾔多个线程之间还能共享相同的内存空间,⽽ JavaScript 的每 个线程都有⾃⼰独有的内存堆。 这意味着 Go 中多个处理单元,例如解析资源 A 的线程,可以直接读取资源 B 线程的运⾏结果,⽽在 JavaScript 中相同的操作需要调⽤通讯接⼝ woker。postMessage 在线程间复制数据。

所以在运⾏时层⾯,Go 拥有天然的多线程能⼒,更⾼效的内存使⽤率,也就意味着更⾼的运⾏性能。

  1. 全量定制

在 Webpack、Rollup 这类⼯具中,⽐如:

  • 使⽤ babel 实现 ES 版本转译;
  • 使⽤ eslint 实现代码检查;
  • 使⽤ TSC 实现 ts 代码转译与代码检查;
  • 使⽤ less、stylus、sass 等 css 预处理⼯具;

这些全部都是插件实现,其实⽬前我们已经完全习惯了这种⽅式,甚⾄觉得事情就应该是这样的,⼤多数⼈可能根本没有意识到事情可以有另⼀种解决⽅案。 Esbuild 起了个头,选择完全重写,整套编译流程所需要⽤到的所有⼯具全部重写,这意味着它需要重写 js、ts、jsx、json 等资源⽂件的加载、解析、链接、代码⽣成逻辑。

开发成本很⾼,⽽且可能被动陷⼊封闭的⻛险,但收益也是巨⼤的,它可以⼀路贯彻原则,以性能为最⾼优先级定制编译的各个阶段,⽐如说:

  • 重写 ts 转译⼯具,完全抛弃 ts 类型检查,只做代码转换;
  • ⼤多数打包⼯具把词法分析、语法分析、符号声明等步骤拆解为多个⾼内聚低耦合的处理单元,各个模块职责分明,可读性、可维护性较⾼。⽽ Esbuild 则坚持性能第⼀原则,不惜采⽤反直觉的设计模式,将多个处理算法混合在⼀起降低编译过程数据流转所带来的性能损耗;
  • ⼀致的数据结构,以及衍⽣出的⾼效缓存策略;

这种深度定制⼀⽅⾯降低了设计成本,能够保持编译链条的架构⼀致性;⼀⽅⾯能够贯彻性能第⼀的原则,确保每个环节以及环节之间交互性能的最优。 虽然伴随着功能、可读性、可维护性层⾯的的牺牲,但在编译性能⽅⾯⼏乎做到了极致。

  1. 结构⼀致性

在 Webpack 中使⽤ babel-loader 处理 JavaScript 代码时,可能需要经过多次数据转换:

  • Webpack 读⼊源码,此时为字符串形式
  • Babel 解析源码,转换为 AST 形式
  • Babel 将源码 AST 转换为低版本 AST
  • Babel 将低版本 AST generate 为低版本源码,字符串形式
  • Webpack 解析低版本源码
  • Webpack 将多个模块打包成最终产物

源码需要经历 string => AST => AST => string => AST => string ,在字符串与 AST 之间反复横跳。

⽽ Esbuild 重写⼤多数转译⼯具之后,能够在多个编译阶段共⽤相似的 AST 结构,尽可能减少字符串到 AST 的结构转换,提升内存使⽤效率。

但与完全重写相对的是,⽬前Esbuild对于很多功能(像Vue、Angular等)⽀持还在逐步实现中,所以在线上环境还为时过早,但就纯编译性能上看,Esbuild可以极具竞争⼒,这也是为什么像Vite、snowpack选择Esbuild编译的原因。

Esbuild使用及特性

Esbuild的特性:

  • 极快的速度,⽆需缓存;
  • ⽀持 ES6 和 CommonJS 模块;
  • ⽀持对 ES6 模块进⾏ tree shaking;
  • API 可同时⽤于 JavaScript 和 Go;
  • 兼容 TypeScript 和 JSX 语法;
  • ⽀持 Source maps;
  • ⽀持 Minification;
  • ⽀持 plugins;

Esbuild的官⽅⽂档中也阐述了在Esbuild中API的使⽤,针对不同类型⽂件的loader配置,plugins的基本使⽤,内容不多,建议直接看官⽅。

相关示例

基于ESModule的bundleless构建工具

browserify、webpack、rollup、parcel这些⼯具的思想都是递归循环依赖,然后组装成依赖树,优化完依赖树后⽣成代码。 但是这样做的缺点就是慢,需要遍历完所有依赖,即使 parcel 利⽤了多核,webpack 也⽀持多线程,在打包⼤型项⽬的时候依然慢可能会⽤上⼏分钟,存在性能瓶颈。 所以基于浏览器原⽣ ESM 的运⾏时打包⼯具出现

基于 Bundle 模式下的Dev-server:

Alt text

基于原生ES module模式下的 dev-server:

Alt text

可以看到,我们只需要打包当前所需要的资源,对于⽽不⽤打包整个项⽬,开发时的体验相⽐于 bundle类的⼯具只能⽤极速来形容。 bundleless类运⾏时打包⼯具的启动速度是毫秒级的,因为不需要打包任何内容,只需要起两个server,⼀个⽤于⻚⾯加载,另⼀个⽤于HMR的WebSocket,当浏览器发出原⽣的ES module请求,server收到请求只需编译当前⽂件后返回给浏览器不需要管依赖。

bundleless 类工具出现背景

⾄于为什么bundleless在近⼏年很出名,原因在于:

HTTP2

因为http1.x不⽀持多路服⽤, HTTP 1.x 中,如果想并发多个请求,必须使⽤多个 TCP 链接,且浏览器为了控制资源,还会对单个域名有 6-8个的TCP链接请求的限制。 因此我们需要做的就是将同域的⼀些静态资源⽐如js等,做⼀个资源合并,将多次请求不同的js⽂件,合并成单次请求⼀个合并后的⼤js⽂件。 其实这也就是webpack的bundle由来。

⽽HTTP2实现了TCP链接的多路复⽤,因此同域名下不再有请求并发数的限制,我们可以同时请求同域名的多个资源,这个并发数可以很⼤,⽐如并发10,50,100个请求同时去请求同⼀个服务下的多个资源。(虽然实际效果有待考量),因为http2实现了多路复⽤,因此⼀定程度上,将多个静态⽂件打包到 ⼀起,从⽽减少请求次数,就不是必须的。

主流浏览器对HTTP2的⽀持情况如下:

Alt text

除了IE以外,⼤部分浏览器对HTTP2的⽀持程度都很好,所以如果不⽤考虑兼容IE低版本,同时也不需要兼容低版本浏览器,不需要考虑不⽀持HTTP2的场景,所以在此情况下,让我们在使⽤bundleless上成为了可能。

浏览器对ESM的支持

在Google Chrome 浏览器下运行下面这段 ES-Module格式的代码:

html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>ES module in browser</title>
</head>
<body>
  <p>test ES Module</p>
  <script type="module">
    import {add, minus} from './js/index.js'
    const x = 4;
    const y = 5;
    console.log('test add function: ', add(x, y));
    console.log('test minus function: ', minus(x, y));
  </script>

</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>ES module in browser</title>
</head>
<body>
  <p>test ES Module</p>
  <script type="module">
    import {add, minus} from './js/index.js'
    const x = 4;
    const y = 5;
    console.log('test add function: ', add(x, y));
    console.log('test minus function: ', minus(x, y));
  </script>

</body>
</html>

注意设置 script type="module"后, 代码可以正常跑通。

浏览器对于HTTP2和ESM的⽀持,使得我们可以减少模块的合并,以及减少对于js模块化的处理。

  • 如果浏览器⽀持HTTP2,那么⼀定程度上,我们不需要合并静态资源;
  • 如果浏览器⽀持ESM,那么我们就不需要通过构建⼯具去维护复杂的模块依赖和加载关系; 这两点正是webpack等打包⼯具在bundle的时候所做的事情。 浏览器对于HTTP2和ESM的⽀持使得我们减少bundle代码的场景

Snowpack

Snowpack 是⼀个⽤于提升 Web 开发效率的轻量级新型构建⼯具。

在开发过程中,Snowpack 为项⽬提供了免打包式(unbundled development) 的服务。 每个⽂件只需构建⼀次就被永远缓存起来。 当⽂件发⽣变化时,Snowpack 重新构建发⽣变化的⽂件然后在浏览器中直接更新,⽽没有在重新打包上浪费时间(通过模块热替换(HMR)实现)。 Snowpack 带来了两全其美的效果: 快速、免打包式的开发,以及打包式⽣产构建中的优化性能。

Alt text

针对npm依赖,NPM 包主要是使⽤模块语法(Common.js,或 CJS)发布的,如果没有⼀些构建处理,就不能在浏览器上运⾏。 虽然⽤浏览器原⽣的 ESM import和export语句编写的代码会直接在浏览器中运⾏,但在导⼊ npm 包后你都会退回到打包式开发时代。 Snowpack 采取了⼀种不同的⽅法: Snowpack 没有因为这个打包整个应⽤程序,⽽是单独处理 npm依赖。 以下是它的⼯作原理。

  1. Snowpack 扫描⽹站/应⽤程序引⼊的所有 npm 包;
  2. Snowpack 从node_modules⽬录中读取这些已安装的依赖包;
  3. Snowpack 将所有 npm 依赖分别打包到单个 JavaScript ⽂件中,例如:react和react-dom分别转换为react.js和react-dom.js;
  4. 每个转换来的⽂件在经过 ESM 的import语句导⼊后,都可以直接在浏览器中运⾏;
  5. 因为 npm 依赖很少改变,Snowpack 很少需要重建它们

因此所有依赖的 NPM 包,都会被 Snowpack 做转换处理:

  • node_modules/react/**/* -> http://localhost:3000/web_modules/react.js
  • node_modules/react-dom/**/* -> http://localhost:3000/web_modules/react-dom.js

在 Snowpack 执⾏完对 npm 依赖的处理后,任何包都可以被导⼊并直接在浏览器中运⾏,不需要额外的打包或⼯具。 这种在浏览器中原⽣导⼊ npm 包的能⼒(⽆需打包器)是所有免打包式开发⼯具和 Snowpack 建⽴的基础。

Snowpack原理

  1. snowpack 会扫描⼯作⽬录下所有源码,得出所有的依赖列表。具体来说,对于 css、less、sass、scss ⽂件,会扫描所有的 @import 语句,对于 html、svelte、vue ⽂件,会扫描所有 <script> 标签内的 import 语句;对于 js、jsx、mjs、ts、tsx ⽂件,会解析所有的 import 语句,得到依赖的 npm 模块名称。
  2. 接下来,根据依赖模块名称,尝试去找 npm 模块⼊⼝⽂件。采⽤的策略和 node 依赖查找机制类 似,找到 package.json ⽂件后,先找 export map 指定的⼊⼝⽂件,再依次找 browser:module、module、main:esnext、browser、main 等字段,保证优先使⽤ ESM ⼊⼝。
  3. 使⽤ rollup 打包,将依赖的模块打包成⼀个个 ESM,从⽽可以直接在浏览器运⾏,输出到缓存⽬录(默认是 ./node_modules/.cache/snowpack/dev)下。这⼀步打包操作也可以同时避免 ESM 依赖地狱的问题。(试想⼀下,如果不对依赖模块提前打包,⽽是通过⼀个个 HTTP 请求来加载node_modules 下所有模块的场景,将会有上百个 http 请求)
  4. 最后把依赖的模块与打包后⽂件绝对路径的映射记录下来,后⾯构建⽂件阶段根据这个映射对业务代码中原有的 import 语句进⾏改写。

Alt text

⾸先,如果是对依赖模块的请求,会直接返回之前构建好的 ESM。 接下来,对于业务代码的处理,则会先经过⽂件的编译过程。 这⼀过程是由不同的插件来实现,分为load 和 transform 两个阶段。 load 阶段是将业务代码编译成为浏览器可以直接运⾏的代码,⽐如TypeScript、JSX 到 JS,Sass 到 CSS。 transform 是对编译后代码做进⼀步的处理,典型的⽐如 PostCss。

使⽤esbuild

mjs、jsx、ts、tsx 这⼏种格式的脚本⽂件浏览器是⽆法直接执⾏的,为此,snowpack 内置了⼀个esbuild 插件,如果⽤户没有显式指定⽤于处理 mjs、jsx、ts、tsx 的插件,那么这个内置的插件就会⽣效,⽤ esbuild 进⾏这⼏类⽂件的编译,从⽽默认⽀持 TypeScript、JSX:

snowpack的相关示例

Vite

Vite被⼴⼤开发者认为是下⼀代的构建⼯具,包含了这些特点:

  • Instant Server Start —— 即时服务启动
  • Lightning Fast HMR —— 闪电般快速的热更新
  • Rich Features —— 丰富的功能
  • Optimized Build —— 经过优化的构建
  • Universal Plugin Interface —— 通⽤的Plugin接⼝
  • Fully Typed APIs —— 类型⻬全的API

针对以往的打包构建⼯具(如webpack),Vite做到了:

  1. 开发环境冷启动⽆需打包 解决了启动慢的问题,⽆需分析模块之间的依赖,同时也⽆需在启动开发服务器前进⾏编译,启动时还会使⽤esbuild来进⾏预构建。 ⽽Webpack 启动后会做⼀堆事情,经历⼀条很⻓的编译打包链条,从⼊⼝开始需要逐步经历语法解析、依赖收集、代码转译、打包合并、代码优化,最终将⾼版本的、离散的 源码编译打包成低版本、⾼兼容性的产物代码;

  2. 优化HMR 针对HMR慢,即使只有很⼩的改动,Webpack依然需要构建完整的模块依赖图,并根据依赖图来进⾏转换。 ⽽Vite利⽤了ESM和浏览器缓存技术,更新速度与项⽬复杂度⽆关。如Snowpack、Vite这类⾯向⾮打包的构建⼯具,在开发环境启动时只需要启动两个Server,⼀个⽤于⻚⾯加载,⼀个⽤于HMR的Websocket。 当浏览器发出原⽣的ESM请求,Server收到请求只需要编译当前⽂件后返回给浏览器,不需要管理依赖。

  3. 使⽤简单,开箱即⽤ 相⽐Webpack需要对entry、loader、plugin等进⾏诸多配置,Vite的使⽤可谓是相当简单了。 只需执⾏初始化命令,就可以得到⼀个预设好的开发环境,开箱即获得⼀堆功能,包括:CSS预处理、html预处理、异步加载、分包、压缩、HMR等。 他使⽤复杂度介于Parcel和Webpack的中间,只是暴露了极少数的配置项和plugin接⼝,既不会像Parcel⼀样配置不灵活,⼜不会像Webpack⼀样需要了解庞⼤的loader、plugin⽣态,灵活适中、复杂度适中。适合前端新⼿。

vite的原理

开发环境:

不需要对所有资源打包,只是使⽤esbuild对依赖进⾏预构建,将CommonJS和UMD发布的依赖转换为浏览器⽀持的ESM,同时提⾼了后续⻚⾯的加载性能(lodash的请求)。 Vite会将预构建的依赖缓存到 node_modules/.vite⽬录下,它会根据⼏个源来决定是否需要重新运⾏预构建,包括 packages.json中的dependencies列表、包管理器的lockfile、可能在vite.config.js相关字段中配置过的。只要三者之⼀发⽣改变,才会重新预构建。 同时,开发环境使⽤了浏览器缓存技术,解析后的依赖请求以http头的 max-age=31536000,immutable 强缓存,以提⾼⻚⾯性能

⽣产环境:

由于嵌套导⼊会导致发送⼤量的⽹络请求,即使使⽤HTTP2.x(多路复⽤、⾸部压缩),在⽣产环境中发布未打包的ESM仍然性能低下。 因此,对⽐在开发环境Vite使⽤esbuild来构建依赖,⽣产环境Vite则使⽤了更加成熟的Rollup来完成整个打包过程。 因为esbuild虽然快,但针对应⽤级别的代码分割、CSS 处理仍然不够稳定,同时也未能兼容⼀些未提供ESM的SDK。 为了在⽣产环境中获得最佳的加载性能,仍然需要对代码进⾏tree-shaking、懒加载以及chunk分割(以获得更好的缓存)

请求拦截

启动⼀个 koa 服务器拦截由浏览器请求 ESM的请求。通过请求的路径找到⽬录下对应的⽂件做⼀定的处理最终以 ESM的格式返回给客户端。 浏览器对 import 的模块发起请求时的⼀些局限,平时我们写代码,如果不是引⽤相对路径的模块,⽽是引⽤ node_modules 的模块,都是直接 import xxx from 'xxx' ,由 Webpack 等⼯具来帮我们找这个模块的具体路径。 但是浏览器不知道你项⽬⾥有 node_modules ,它只能通过相对路径去寻找模块。

因此 Vite 在拦截的请求⾥,对直接引⽤ node_modules 的模块都做了路径的替换,换成了 /@modules/ 并返回回去。 ⽽后浏览器收到后,会发起对 /@modules/xxx 的请求,然后被 Vite 再次拦截,并由 Vite 内部去访问真正的模块,并将得到的内容再次做同样的处理后,返回给浏览器。

总结

从最初的grant、gulp 不太完善的打包构建体系,到大一统的前端构建基石 Webpack,前端的生态体系不断完善,不断演进每年都有新的变化和发展。 后来者 Parcel 尝试解决 Webpack 配置复杂繁琐和打包慢的问题,一时广受关注。 Rollup基于ESM推出了tree shaking的特性,吸引了人们的关注,也引得 Webpack 去借鉴。 后来的 Webpack5 也在改进,简化配置,支持多线程打包,但终究还是一个bundle 模式的打包工具。

随后SWC(rust 开发)和 esbuild(go开发)两款非 JS 语言开发的工具,将打包构建性能推向了极致。

伴随 http2 和 原生ESM的普及,bundleless 模式的打包工具涌现出来。早期的 Snowpack 和 后来的 vite,将前端打包构建工具带向了另一个方向:no bundle 模式。

参考链接